昨天理解了其他程式語言的記憶體管理方式後,今天來聊聊 Rust 如何利用所有權系統來達到安全地使用記憶體 (Memory Safety)。
先簡單用個範例了解一下 Rust 怎麼將資料放在記憶體中,另外其實 Rust 會自動做型別推斷,這裡寫出來是更容易看懂:
fn main() {
fn a() {
let x: &str = "hello";
let y: i32 = 22;
b();
println!("x in a function is {}, y in a function is {}", x, y);
}
fn b() {
let z: String = String::from("world");
println!("z in b function is {}", z);
}
a();
}
在看這段程式之前,要先能理解兩種字串型別 (ref):
&str
是一種被稱為 string literal 的字串型別,是指固定大小且不可變的字串值,就像上面的 x
String
的字串型別指的是大小可以動態改變的字串,在初始化之後還能繼續用 push_str
來將新的字串接在後面,就像上面的 z
參考上圖,當這段程式被執行時,會依序將函式的呼叫資訊放進 stack 記憶體中。main
中呼叫 a
函式,而 a
函式裡會去宣告兩個 local 變數 x
跟 y
,因為這兩個變數都是定值,所以也都被存在 stack 裡面。
接著 a
會再去呼叫 b
函式,此時 b
函式的呼叫資訊會繼續堆疊在 main
與 a
之上,而 b
中宣告了一個動態的字串 z
,因為這個 String
是大小可以動態改變的字串,所以會需要配置一段記憶體在 heap 中,並只在 stack 上存著指到這塊記憶體位址的指標。
從上面這個範例可以看到,在 Rust 中要配製一塊動態記憶體相當平易近人,你不需要像 C++ 一樣需要去用 new
與 delete
分配與釋放記憶體,Rust 都幫你做好了,但方便歸方便,要能達到 memory safety 還需要仰賴這個所謂的所有權系統。
先來看一個例子:
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
}
這段程式如果套到類似的 JavaScript 的語法來理解乍看之下沒什麼問題,但如果今天去 cargo run
執行看看後,會得到這樣的錯誤:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:28
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{}, world!", s1);
| ^^ value borrowed here after move
|
從錯誤訊息中會看到幾個關鍵字 move
、Copy trait
、borrow
、clone
,以下我們就一個一個來理解這些觀念。
在文件最開頭提到所有權系統會遵循三個鐵則,可以先記下來這些特性,下面會逐一看例子來理解:
這裡看個例子,Rust 可以用一個 { }
直接去建立出一個 block scope:
fn main() {
// s 還沒被宣告,這裡還讀取不到
{
// 宣告 s
let s = String::from("hello");
// 在這個作用域裡面可以存取到 s
println!("{}", s);
}// 作用域結束,s 這塊記憶體已經被自動釋放
// 印出這行時會報錯,因為 s 在離開作用域後就已經消失
println!("{}", s);
}
再來看另一個例子:
fn main() {
let x: i32 = 5;
let y: i32 = x; // Copy
println!("x 的值是 {}, y 的值是 {}", x, y);
let s1: String = String::from("hello");
let s2: String = s1; // move
println!("s1 的值是 {}, s2 的值是 {}", s1, s2); // 出錯
}
在 Rust 中,簡單型別像是整數、浮點數、boolean、char 等,會具備 Copy
的特性,在傳遞變數時,會直接複製一份值過去。
但對動態大小的型別像是 String
、Vec
、自定義的 Struct
等,在傳遞變數時,參考上圖左邊,會將指標或稱記憶體位置複製一份給新變數。但如果像是上圖中間一樣也直接連在 heap 中的資料都複製一份的話,這樣會造成記憶體的使用成本太高,因此實際 Rust 會執行的是做記憶體所有權的轉移 (Move) ,並將 s1
給無效化,這個動作就稱為 move
。
另外筆記下覺得 The book 中有個比喻也蠻不錯的,如果上面看不懂也可以參考看看 (ref):
📝 如果你在其他語言聽過淺拷貝(shallow copy)和深拷貝(deep copy)這樣的詞,拷貝指標、長度和容量而沒有拷貝實際內容這樣的概念應該就相近於淺拷貝。但因為 Rust 同時又無效化第一個變數,我們不會叫此為淺拷貝,而是稱此動作為移動(move)。
再來看另一個例子,這段程式中想要利用 calculate_length
這個函式來幫忙根據傳入的字串算長度:
fn calculate_length(s: String) -> usize {
let length = s.len();
length
}
fn main() {
let s1 = String::from("hello");
let len = calculate_length(s1);
println!("'{}' 的長度為 {}。", s1, len);
}
因為 s1
的所有權已經 move 給 calculate_length
了,所以當在 main
中最後想再印出 s1
會出錯,那如果想修正這段程式該怎麼做,有個最簡單的方式就是用 clone
:
fn calculate_length(s: String) -> usize {
let length = s.len();
length
}
fn main() {
let s1 = String::from("hello");
let len = calculate_length(s1.clone());
println!("'{}' 的長度為 {}。", s1, len);
}
其實這就是在做上圖中間的那個操作,clone
這個行為讓開發者有意識地知道這裡進行了一個昂貴的記憶體複製。但只是為了將資料傳進去算長度就複製一份實在偏浪費,還有另一種修正方式就是將所有權用 tuple 的方式傳回來:
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
// 將原本傳入的 s 的所有權再傳回去
(s, length)
}
fn main() {
let s1 = String::from("hello");
// 用 s2 拿回原本 s1 的所有權
let (s2, len) = calculate_length(s1);
println!("'{}' 的長度為 {}。", s2, len);
}
但這樣寫實在偏麻煩,如果不想要 clone
也不想將所有權丟來丟去的話,是不是還有更好的解法?
還真有,就是不要把所有權讓出去,只是稍微借 (borrow) 出去就好。但因為再不出去吃飯餐廳要關門了,所以容我明天會再從 borrow 的觀念繼續講完來個精彩大完結,順便對這個系列收個尾。